package com.google.code.jtasks.bugzilla;

import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Set;

import org.apache.xmlrpc.XmlRpcRequest;
import org.apache.xmlrpc.client.XmlRpcClient;
import org.apache.xmlrpc.client.XmlRpcClientConfigImpl;
import org.apache.xmlrpc.client.XmlRpcClientException;
import org.apache.xmlrpc.client.XmlRpcSunHttpTransport;
import org.apache.xmlrpc.client.XmlRpcSunHttpTransportFactory;
import org.apache.xmlrpc.client.XmlRpcTransport;
import org.apache.xmlrpc.client.XmlRpcTransportFactory;

import com.google.code.jtasks.adapters.IRepositoryMetadataProvider;
import com.google.code.jtasks.adapters.ITaskSystemAdapter;
import com.google.code.jtasks.adapters.RepositoryMetadata;
import com.google.code.jtasks.bugzilla.proxy.bug.BugCommentsProxy;
import com.google.code.jtasks.bugzilla.proxy.bug.BugFieldsProxy;
import com.google.code.jtasks.bugzilla.proxy.bug.BugHistoryProxy;
import com.google.code.jtasks.bugzilla.proxy.bug.BugSearchProxy;
import com.google.code.jtasks.bugzilla.proxy.bug.BugCommentsProxy.Comment;
import com.google.code.jtasks.bugzilla.proxy.bug.BugCommentsProxy.CommentsResult;
import com.google.code.jtasks.bugzilla.proxy.bug.BugCommentsProxy.CommentsSelector;
import com.google.code.jtasks.bugzilla.proxy.bug.BugFieldsProxy.FieldsParameters;
import com.google.code.jtasks.bugzilla.proxy.bug.BugFieldsProxy.FieldsResult;
import com.google.code.jtasks.bugzilla.proxy.bug.BugHistoryProxy.HistoryEntry;
import com.google.code.jtasks.bugzilla.proxy.bug.BugHistoryProxy.HistoryResult;
import com.google.code.jtasks.bugzilla.proxy.bug.BugHistoryProxy.HistorySelector;
import com.google.code.jtasks.bugzilla.proxy.bug.BugSearchProxy.Bug;
import com.google.code.jtasks.bugzilla.proxy.bug.BugSearchProxy.FieldIncludeFilters;
import com.google.code.jtasks.bugzilla.proxy.bug.BugSearchProxy.SearchResult;
import com.google.code.jtasks.bugzilla.proxy.user.UserLoginProxy;
import com.google.code.jtasks.bugzilla.proxy.user.UserLoginProxy.LoginParams;
import com.google.code.jtasks.bugzilla.proxy.user.UserLoginProxy.LoginResult;
import com.google.code.jtasks.connection.TaskRepoConnectionSettings;
import com.google.code.jtasks.connection.TaskRepoId;
import com.google.code.jtasks.model.IMappedTask;
import com.google.code.jtasks.model.TaskPropertyValue;
import com.google.code.jtasks.model.IMappedTask.TaskField;
import com.google.code.jtasks.query.TaskQuery;
import com.google.code.jtasks.query.TaskQueryResult;
import com.google.code.jtasks.util.BadCodeMonkeyException;
import com.google.common.collect.Multimap;
import com.google.inject.Inject;

/**
 * This {@link ITaskSystemAdapter} implementation supports the Bugzilla task system.
 */
public final class BugzillaTaskSystemAdapter implements
		ITaskSystemAdapter<BugzillaRepositoryData, BugzillaSessionData>
{
	private final IRepositoryMetadataProvider repoMetadataProvider;

	/**
	 * Constructs a new {@link BugzillaTaskSystemAdapter} instance.
	 * 
	 * @param repoMetadataProvider
	 *            the {@link IRepositoryMetadataProvider} that this {@link ITaskSystemAdapter} should use to
	 *            store any per-repository state
	 */
	@Inject
	public BugzillaTaskSystemAdapter(IRepositoryMetadataProvider repoMetadataProvider)
	{
		this.repoMetadataProvider = repoMetadataProvider;
	}

	/**
	 * @see com.google.code.jtasks.adapters.ITaskSystemAdapter#getUnsupportedTaskFields()
	 */
	@Override
	public Set<TaskField> getUnsupportedTaskFields()
	{
		Set<TaskField> unsupportedTaskFields = new HashSet<TaskField>();
		unsupportedTaskFields.add(TaskField.URL);
		return unsupportedTaskFields;
	}

	/**
	 * @see com.google.code.jtasks.adapters.ITaskSystemAdapter#runQuery(com.google.code.jtasks.query.TaskQuery)
	 */
	@Override
	public TaskQueryResult runQuery(TaskQuery query)
	{
		XmlRpcClient rpcClient = createRpcClient(query.getConnectionSettings());

		initializeSessionData(query.getConnectionSettings(), rpcClient);
		initializeRepositoryData(query.getConnectionSettings(), rpcClient);

		FieldIncludeFilters fieldIncludeFilters = toFieldIncludeFilters(query);
		SearchResult searchResult = new BugSearchProxy(rpcClient).search(fieldIncludeFilters);
		CommentsResult commentsResult = new BugCommentsProxy(rpcClient)
				.comments(toCommentsSelector(searchResult));
		HistoryResult historyResult = new BugHistoryProxy(rpcClient).history(toHistorySelector(searchResult));

		// Process the results
		TaskRepoId taskRepoId = query.getConnectionSettings().getTaskRepoId();
		List<BugContainer> bugContainers = createBugContainers(taskRepoId, searchResult, commentsResult,
				historyResult);
		List<IMappedTask> tasks = createTasks(repoMetadataProvider, bugContainers);

		return new TaskQueryResult(tasks);
	}

	/**
	 * Creates a new {@link XmlRpcClient} to communicate with the specified task repository.
	 * 
	 * @param connectionSettings
	 *            the {@link TaskRepoConnectionSettings} for the task repository to communicate with
	 * @return a new {@link XmlRpcClient} to communicate with the specified task repository
	 */
	private XmlRpcClient createRpcClient(TaskRepoConnectionSettings connectionSettings)
	{
		URL rpcUrl;
		try
		{
			rpcUrl = new URL(connectionSettings.getUrl(), "xmlrpc.cgi");
		}
		catch (MalformedURLException e)
		{
			throw new BadCodeMonkeyException("Unexpected error building URL.", e);
		}
		XmlRpcClientConfigImpl rpcConfig = new XmlRpcClientConfigImpl();
		rpcConfig.setServerURL(rpcUrl);

		XmlRpcClient rpcClient = new XmlRpcClient();
		rpcClient.setConfig(rpcConfig);

		// Customize the transport used by the client (required for proper cookie handling)
		RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repositoryMetadata = getRepoMetadata(connectionSettings);
		XmlRpcTransportFactory transportFactory = new CustomXmlRpcTransportFactory(rpcClient,
				repositoryMetadata);
		rpcClient.setTransportFactory(transportFactory);

		return rpcClient;
	}

	/**
	 * Returns the {@link RepositoryMetadata} for the specified {@link TaskRepoConnectionSettings}.
	 * 
	 * @param connectionSettings
	 *            the {@link TaskRepoConnectionSettings} to get the {@link RepositoryMetadata} for
	 * @return the {@link RepositoryMetadata} for the specified {@link TaskRepoConnectionSettings}
	 */
	@SuppressWarnings("unchecked")
	private RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> getRepoMetadata(
			TaskRepoConnectionSettings connectionSettings)
	{
		return (RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData>) repoMetadataProvider
				.getRepositoryMetadata(connectionSettings.getTaskRepoId());
	}

	/**
	 * Initializes the {@link BugzillaSessionData} for the specified task repository, if it hasn't already
	 * been.
	 * 
	 * @param connectionSettings
	 *            the {@link TaskRepoConnectionSettings} for the task repository to initialize the
	 *            {@link BugzillaSessionData} for
	 * @param rpcClient
	 *            an {@link XmlRpcClient} that can be used to connect to the specified task repository
	 */
	private void initializeSessionData(TaskRepoConnectionSettings connectionSettings, XmlRpcClient rpcClient)
	{
		RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repoMetadata = getRepoMetadata(connectionSettings);
		BugzillaSessionData sessionData = repoMetadata.getSessionData();

		// If no auth cookies have been stored, login (will save result to session data)
		if (sessionData == null || sessionData.getAuthCookiesCache().isEmpty())
			login(rpcClient, connectionSettings);
	}

	/**
	 * Initializes the {@link BugzillaRepositoryData} for the specified task repository, if it hasn't already
	 * been.
	 * 
	 * @param taskRepoConnectionSettings
	 *            the {@link TaskRepoConnectionSettings} for the task repository to initialize the
	 *            {@link BugzillaRepositoryData} for
	 * @param rpcClient
	 *            an {@link XmlRpcClient} that can be used to connect to the specified task repository
	 */
	private void initializeRepositoryData(TaskRepoConnectionSettings connectionSettings,
			XmlRpcClient rpcClient)
	{
		RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repoMetadata = getRepoMetadata(connectionSettings);
		BugzillaRepositoryData repoData = repoMetadata.getRepositoryData();

		// If no repository data has been collected, do so
		if (repoData == null)
		{
			// Collect data on the repository's fields
			BugFieldsProxy fieldsProxy = new BugFieldsProxy(rpcClient);
			FieldsResult fieldsResult = fieldsProxy.fields(new FieldsParameters());

			// Build and save the repository data
			repoData = new BugzillaRepositoryData(fieldsResult);
			repoMetadata.setRepositoryData(repoData);
		}
	}

	/**
	 * Returns the {@link FieldIncludeFilters} represented by the specified {@link TaskQuery}.
	 * 
	 * @param query
	 *            the {@link TaskQuery} to convert
	 * @return the {@link FieldIncludeFilters} represented by the specified {@link TaskQuery}
	 */
	private static FieldIncludeFilters toFieldIncludeFilters(TaskQuery query)
	{
		// Loop through each of the field filters, converting it to a search parameter
		FieldIncludeFilters fieldIncludeFilters = new FieldIncludeFilters();
		for (TaskField taskField : query.getFieldInclusiveFilters().keySet())
		{
			Set<TaskPropertyValue<?>> allowedValues = query.getFieldInclusiveFilters().get(taskField);
			BugField bugField = BugField.findTaskField(taskField);
			bugField.translate(allowedValues, fieldIncludeFilters);
		}

		return fieldIncludeFilters;
	}

	/**
	 * Returns a {@link CommentsSelector} that will select comments for the bugs in the specified
	 * {@link SearchResult}.
	 * 
	 * @param searchResult
	 *            the {@link SearchResult} containing the bugs to get comments for
	 * @return a {@link CommentsSelector} that will select comments for the bugs in the specified
	 *         {@link SearchResult}
	 */
	private static CommentsSelector toCommentsSelector(SearchResult searchResult)
	{
		List<Integer> bugIds = new ArrayList<Integer>();
		for (Bug bug : searchResult.getBugs())
			bugIds.add(bug.getId());

		CommentsSelector selector = new CommentsSelector();
		selector.filterByIds(bugIds);
		return selector;
	}

	/**
	 * Returns a {@link HistorySelector} that will select the history of the bugs in the specified
	 * {@link SearchResult}.
	 * 
	 * @param searchResult
	 *            the {@link SearchResult} containing the bugs to get comments for
	 * @return a {@link HistorySelector} that will select the history of the bugs in the specified
	 *         {@link SearchResult}
	 */
	private static HistorySelector toHistorySelector(SearchResult searchResult)
	{
		List<Integer> bugIds = new ArrayList<Integer>();
		for (Bug bug : searchResult.getBugs())
			bugIds.add(bug.getId());

		HistorySelector selector = new HistorySelector();
		selector.filterByIds(bugIds);
		return selector;
	}

	/**
	 * Returns {@link BugContainer}s that group the specified {@link Bug}s, {@link Comment}s, and
	 * {@link HistoryEntry}s.
	 * 
	 * @param taskRepoId
	 *            the {@link TaskRepoId} that identifies the Bugzilla server the {@link SearchResult} is from
	 * @param searchResult
	 *            contains the {@link Bug}s to be grouped
	 * @param commentsResult
	 *            contains the {@link Comment}s to be grouped
	 * @param historyResult
	 *            contains the {@link HistoryEntry}s to be grouped
	 * @return {@link BugContainer}s that group the specified {@link Bug}s, {@link Comment}s, and
	 *         {@link HistoryEntry}s
	 */
	private static List<BugContainer> createBugContainers(TaskRepoId taskRepoId, SearchResult searchResult,
			CommentsResult commentsResult, HistoryResult historyResult)
	{
		Map<Integer, Bug> bugs = new HashMap<Integer, Bug>();
		for (Bug bug : searchResult.getBugs())
			bugs.put(bug.getId(), bug);

		Multimap<Integer, Comment> comments = commentsResult.getComments();
		Multimap<Integer, HistoryEntry> history = historyResult.getHistory();

		List<BugContainer> bugContainers = new ArrayList<BugContainer>();
		for (Integer bugId : bugs.keySet())
		{
			Bug bug = bugs.get(bugId);
			Collection<Comment> bugComments = comments.get(bugId);
			Collection<HistoryEntry> bugHistory = history.get(bugId);
			BugContainer bugContainer = new BugContainer(taskRepoId, bug, bugComments, bugHistory);
			bugContainers.add(bugContainer);
		}

		return bugContainers;
	}

	/**
	 * Returns {@link IMappedTask}s for all of the bugs in the specified {@link BugContainer}s.
	 * 
	 * @param repoMetadataProvider
	 *            the {@link IRepositoryMetadataProvider} that the {@link BugzillaMappedTask}s should use
	 * @param bugContainers
	 *            the {@link BugContainer}s containing the bugs to create {@link IMappedTask}s for
	 * @return {@link IMappedTask}s for all of the bugs in the specified {@link BugContainer}s
	 */
	private static List<IMappedTask> createTasks(IRepositoryMetadataProvider repoMetadataProvider,
			List<BugContainer> bugContainers)
	{
		List<IMappedTask> tasks = new ArrayList<IMappedTask>();
		for (BugContainer bugContainer : bugContainers)
		{
			IMappedTask task = new BugzillaMappedTask(repoMetadataProvider, bugContainer);
			tasks.add(task);
		}
		return tasks;
	}

	/**
	 * Attempts to login to Bugzilla using the authentication credentials in the specified
	 * {@link TaskRepoConnectionSettings}.
	 * 
	 * @param rpcClient
	 *            the {@link XmlRpcClient} to use
	 * @param connectionSettings
	 *            the {@link TaskRepoConnectionSettings} to use
	 * 
	 * @return the numeric id of the user that was logged in
	 */
	public int login(XmlRpcClient rpcClient, TaskRepoConnectionSettings connectionSettings)
	{
		// Build the parameters for the RPC call
		LoginParams loginParams = new LoginParams();
		loginParams.setLogin(connectionSettings.getUserName());
		loginParams.setPassword(connectionSettings.getPassword());
		loginParams.setRemember(true);

		// Make the call
		UserLoginProxy loginProxy = new UserLoginProxy(rpcClient);
		LoginResult loginResult = loginProxy.login(loginParams);

		// Return the result
		Integer userId = loginResult.getId();
		return userId;
	}

	/**
	 * This extension of {@link XmlRpcSunHttpTransportFactory} generates {@link CustomXmlRpcTransport}
	 * instances to ensure that Bugzilla's authentication cookies are handled correctly.
	 */
	private static final class CustomXmlRpcTransportFactory extends XmlRpcSunHttpTransportFactory
	{
		private final RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repositoryMetadata;

		/**
		 * Creates a new {@link CustomXmlRpcTransportFactory} instance.
		 * 
		 * @param client
		 *            The {@link XmlRpcClient} that will make use of the {@link XmlRpcTransport}s created.
		 * @param repositoryMetadata
		 *            the {@link RepositoryMetadata} for the task repository being connected to
		 */
		public CustomXmlRpcTransportFactory(XmlRpcClient client,
				RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repositoryMetadata)
		{
			super(client);

			this.repositoryMetadata = repositoryMetadata;
		}

		/**
		 * @see org.apache.xmlrpc.client.XmlRpcSunHttpTransportFactory#getTransport()
		 */
		@Override
		public XmlRpcTransport getTransport()
		{
			return new CustomXmlRpcTransport(getClient(), repositoryMetadata);
		}
	}

	/**
	 * This extension of {@link XmlRpcSunHttpTransport} ensures that Bugzilla's authentication cookies are
	 * handled correctly.
	 */
	private static final class CustomXmlRpcTransport extends XmlRpcSunHttpTransport
	{
		private final RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repositoryMetadata;

		/**
		 * Creates a new {@link CustomXmlRpcTransport} instance.
		 * 
		 * @param client
		 *            The {@link XmlRpcClient} that will make use of this {@link CustomXmlRpcTransport}.
		 * @param repositoryMetadata
		 *            the {@link RepositoryMetadata} for the task repository being connected to
		 */
		public CustomXmlRpcTransport(XmlRpcClient client,
				RepositoryMetadata<BugzillaRepositoryData, BugzillaSessionData> repositoryMetadata)
		{
			super(client);

			this.repositoryMetadata = repositoryMetadata;
		}

		/**
		 * @see org.apache.xmlrpc.client.XmlRpcHttpTransport#initHttpHeaders(org.apache.xmlrpc.XmlRpcRequest)
		 */
		@Override
		protected void initHttpHeaders(XmlRpcRequest pRequest) throws XmlRpcClientException
		{
			super.initHttpHeaders(pRequest);

			pushCookies(getURLConnection());
		}

		/**
		 * Adds any cached cookies to the request header for the specified {@link URLConnection}.
		 * 
		 * @param urlConnection
		 *            the {@link URLConnection} to add the cached cookies to
		 */
		private void pushCookies(URLConnection urlConnection)
		{
			BugzillaSessionData sessionData = repositoryMetadata.getSessionData();
			if (sessionData != null)
			{
				StringBuilder cookiesHeaderValue = new StringBuilder();
				ListIterator<String> cookiesIter = sessionData.getAuthCookiesCache().listIterator();
				while (cookiesIter.hasNext())
				{
					cookiesHeaderValue.append(cookiesIter.next());
					if (cookiesIter.hasNext())
						cookiesHeaderValue.append(',');
				}

				setRequestHeader("Cookie", cookiesHeaderValue.toString());
			}
		}

		/**
		 * @see org.apache.xmlrpc.client.XmlRpcSunHttpTransport#close()
		 */
		@Override
		protected void close() throws XmlRpcClientException
		{
			super.close();

			pullCookies(getURLConnection());
		}

		/**
		 * Finds and caches any cookies in the header fields of the specified {@link URLConnection}.
		 * 
		 * @param urlConnection
		 *            the {@link URLConnection} to pull cookies from
		 */
		private void pullCookies(URLConnection urlConnection)
		{
			Map<String, List<String>> headerFields = urlConnection.getHeaderFields();

			if (headerFields.containsKey("Set-Cookie"))
			{
				BugzillaSessionData sessionData = new BugzillaSessionData(headerFields.get("Set-Cookie"));
				repositoryMetadata.setSessionData(sessionData);
			}
		}
	}
}
